Выполнил: Киселев Дмитрий
Задача:
Провести оценку результатов A/B-теста. Имеется датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов.
Техническое задание
recommender_system_test;product_page,product_cart,purchase.Описание данных
ab_project_marketing_events.csv — календарь маркетинговых событий на 2020 год.
name — название маркетингового события;regions — регионы, в которых будет проводиться рекламная кампания;start_dt — дата начала кампании;finish_dt — дата завершения кампании.final_ab_new_users.csv — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года.
user_id — идентификатор пользователя;first_date — дата регистрации;region — регион пользователя;device — устройство, с которого происходила регистрация.final_ab_events.csv — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.
user_id — идентификатор пользователя;event_dt — дата и время события;event_name — тип события;details — дополнительные данные о событии.final_ab_participants.csv — таблица участников тестов.
user_id — идентификатор пользователя;ab_test — название теста;group — группа пользователя.Оглавление
import pandas as pd
import numpy as np
import datetime as dt
import matplotlib.pyplot as plt
from matplotlib import style
import plotly.express as px
import plotly.graph_objects as go
import warnings
warnings.filterwarnings("ignore")
from scipy import stats as st
import math as mth
from collections import Counter
from collections import OrderedDict
try:
marketing_events = pd.read_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\final_project_AB\ab_project_marketing_events.csv',index_col = False)
logs = pd.read_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\final_project_AB\final_ab_events.csv',index_col = False)
new_users = pd.read_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\final_project_AB\final_ab_new_users.csv',index_col = False)
test_participants = pd.read_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\final_project_AB\final_ab_participants.csv',index_col = False)
except:
print ('Не удалось загрузить данные.')
marketing_events.sample(7)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 4 | 4th of July Promo | N.America | 2020-07-04 | 2020-07-11 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
| 6 | Chinese New Year Promo | APAC | 2020-01-25 | 2020-02-07 |
logs.sample(7)
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 439526 | 3DD3723C702398A9 | 2020-12-29 01:01:07 | login | NaN |
| 215234 | 911590D8A90415B7 | 2020-12-22 01:46:02 | product_page | NaN |
| 363177 | 7DFB08AFF4EB2980 | 2020-12-20 12:32:07 | login | NaN |
| 55293 | F89366E30C660671 | 2020-12-25 20:44:26 | purchase | 4.99 |
| 102148 | 6BCDA4A4A576D8DF | 2020-12-21 17:14:09 | product_cart | NaN |
| 18130 | 22323FAB62707AE9 | 2020-12-14 11:19:19 | purchase | 4.99 |
| 424233 | 2089E98A80046417 | 2020-12-26 07:50:42 | login | NaN |
new_users.sample(7)
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 46424 | F425F6890842B239 | 2020-12-12 | CIS | PC |
| 19726 | F772957CBAD38DFA | 2020-12-08 | EU | iPhone |
| 39221 | 9063D28194ABB443 | 2020-12-17 | EU | iPhone |
| 15028 | 2AAC581A99BF546B | 2020-12-21 | N.America | iPhone |
| 51982 | 52957755E79804FD | 2020-12-19 | EU | PC |
| 10051 | E7973F1DE381825F | 2020-12-14 | N.America | Android |
| 53482 | 15A29B69CAAA93E2 | 2020-12-13 | APAC | PC |
test_participants.sample(7)
| user_id | group | ab_test | |
|---|---|---|---|
| 17051 | AB689DF4916E7F8B | B | interface_eu_test |
| 9805 | 6E8742D4710E0E8C | B | interface_eu_test |
| 5481 | B4BA80B4BD6D550C | A | recommender_system_test |
| 7959 | 0F2C0374AF160DA0 | A | interface_eu_test |
| 13129 | A0447E59526C8746 | A | interface_eu_test |
| 13045 | 4F42348E110F703D | B | interface_eu_test |
| 7058 | 82A56AC2F158FDA6 | A | interface_eu_test |
# проверим датасеты на наличие полных дублирующих записей
for df in [marketing_events,logs,new_users,test_participants]:
print('В датасете содержится {0} полных дубликатов'.format(df.duplicated().sum()))
В датасете содержится 0 полных дубликатов В датасете содержится 0 полных дубликатов В датасете содержится 0 полных дубликатов В датасете содержится 0 полных дубликатов
# Прежде чем работать с таблицами, изучим форматы столбцов, при необходимости изменим их.
for df in [marketing_events,logs,new_users,test_participants]:
print("___________")
df.info()
print("___________")
___________ <class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes ___________ ___________ <class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB ___________ ___________ <class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB ___________ ___________ <class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB ___________
# переведем столбцы с датами в нужный формат
for each in [marketing_events['start_dt'],
marketing_events['finish_dt'],
new_users['first_date']]:
each=each.map(lambda x: dt.datetime.strptime(x, '%Y-%m-%d'))
logs['event_dt']=logs['event_dt'].map(lambda x: dt.datetime.strptime(x, '%Y-%m-%d %H:%M:%S'))
fig = px.timeline(marketing_events, x_start='start_dt', x_end='finish_dt', y='name', color='regions')
fig.update_yaxes(autorange='reversed')
fig.show()
Нас интресует поведение пользователей, набранным из EU в период с 7 по 21 декабря 2020 года на логах 7 декабря - 14 января 2021. Заметим, что данный период совпадает с маркетинговыми кампаниями из списка: есть пересечение с активностью Christmas&NY Promo с 25 декабря по по 3 января. Изучим, как это влияет на параметры тестирования и саму возможность его корректного проведения. Также если отмерять 2 недели с прошлой активности - то Black Friday мероприятия могут иметь эффект на аудиторию, хотя сама активность и не такая продолжительная.
#число уникальных пользователей в новых регистрациях равно длине датасета, дублей нет.
new_users.user_id.nunique()
61733
# взглянем как записи регистрации распределены во всех типах устройств для EU
for item in new_users.device.unique() :
df=new_users.query('device == @item and region == "EU"').pivot_table(index=(['first_date']), values=['user_id'], aggfunc=({'user_id' : 'count'})).sort_values(by='first_date',ascending=True)
plt.figure(figsize=(20, 5))
plt.bar(df.index, df.user_id,alpha=0.8)
plt.title(f'Распределение регистраций для {item}')
plt.xlabel('Дата')
plt.ylabel('Кол-во пользователей')
plt.show()
Пропущенных дней нет, паттерны для всех устройств одинаковые: число регистраций увеличивается к выходным и достигает локальных пиков по воскресеньям. Кстати данных о регистрациях оказалось на пару дней больше - нас интересует только период до 21 дек. включительно. Будет полезно сразу отфильтровать датасет для дальнейшей работы - нам также нужна аудитория именно из EU.
#сосчитаем сколько у нас всего пользователей зарегистрировано в тестовый период 15-ти дней
eu_new_users=new_users.query('region == "EU" and first_date <="2020-12-21"')
eu_new_users.describe()
| user_id | first_date | region | device | |
|---|---|---|---|---|
| count | 42340 | 42340 | 42340 | 42340 |
| unique | 42340 | 15 | 1 | 4 |
| top | D72A72121175D8BE | 2020-12-21 | EU | Android |
| freq | 1 | 4650 | 42340 | 18828 |
(test_participants
.groupby(['group','ab_test'])
.agg(unique_users=('user_id', 'nunique'))
.sort_values(by='unique_users', ascending=False))
| unique_users | ||
|---|---|---|
| group | ab_test | |
| A | interface_eu_test | 5831 |
| B | interface_eu_test | 5736 |
| A | recommender_system_test | 3824 |
| B | recommender_system_test | 2877 |
Неожиданно в файле с тестами содержатся пользователи из другого неизвестного эксперимента с названием interface_eu_test. Придется проверить пересечение аудиторий, так как нам не нужны пользователи, которые подверглись двойному эксперименту. От этого логика построения вывода об эффективности конкретной доработки recommender_system_test рушится.
# проверим существует ли проблема пересечения аудитории и в каких комбинациях это происходит
(test_participants
.groupby(['user_id'])
.agg(count_test=('ab_test', 'nunique'),groups=('group', pd.unique))
.sort_values(by='count_test', ascending=False)[:15])
| count_test | groups | |
|---|---|---|
| user_id | ||
| BD23AF6242CA944B | 2 | B |
| 395461648C47B5E1 | 2 | A |
| 72073BB7348162AE | 2 | B |
| CC705D51C74418AD | 2 | [A, B] |
| 19AC976D25997BE9 | 2 | B |
| 9918D1C354A1383A | 2 | [B, A] |
| 198FEF39910DCF76 | 2 | [A, B] |
| 3981FFA649C17699 | 2 | [A, B] |
| E8A94F271024D66C | 2 | [B, A] |
| E8ABD82DC89CD84B | 2 | B |
| E8BBE017F65D7CEA | 2 | B |
| 98DCFB219F33229E | 2 | A |
| AFED749D320D5B38 | 2 | B |
| 98D45D3A9D7E3911 | 2 | [B, A] |
| AFF296F0FD540E4B | 2 | [B, A] |
# нас интересует пересечения между тестами и группами
for test in ['recommender_system_test','interface_eu_test']:
common_users_same_test=Counter(test_participants.query('ab_test == @test & group=="A"')['user_id'].unique()) & Counter(test_participants.query('ab_test == @test & group=="B"')['user_id'].unique())
print (f'Пересечение групп A и B внутри теста {test} составило {len(common_users_same_test)} пользователей')
common_users_dif_test1=Counter(test_participants.query('ab_test == @test & group=="A"')['user_id'].unique()) & Counter(test_participants.query('ab_test != @test & group=="B"')['user_id'].unique())
print (f'Пересечение группы A теста {test} и группы B из другого теста составило {len(common_users_dif_test1)} пользователей')
Пересечение групп A и B внутри теста recommender_system_test составило 0 пользователей Пересечение группы A теста recommender_system_test и группы B из другого теста составило 439 пользователей Пересечение групп A и B внутри теста interface_eu_test составило 0 пользователей Пересечение группы A теста interface_eu_test и группы B из другого теста составило 337 пользователей
common_users=Counter(test_participants.query('ab_test == "recommender_system_test" & group=="A"')['user_id'].unique()) & Counter(test_participants.query('ab_test == "interface_eu_test" & group=="A"')['user_id'].unique())
print(f'Пересечение групп A между разными тестами {len(common_users)} пользователей')
common_users=Counter(test_participants.query('ab_test == "recommender_system_test" & group=="B"')['user_id'].unique()) & Counter(test_participants.query('ab_test == "interface_eu_test" & group=="B"')['user_id'].unique())
print(f'Пересечение групп B между разными тестами {len(common_users)} пользователей')
Пересечение групп A между разными тестами 482 пользователей Пересечение групп B между разными тестами 344 пользователей
Итак, внутри одного теста группы не пересекаются, это правильно. Но аудитория интересующего нас теста recommender_system_test имееет критичное для выводов пересечение с другой экспериментальной группой B теста interface_eu_test, отфильтруем датасет. Далее проверим распределение участников теста по дате регистрации пользователей из EU.
excluded_user_id=test_participants.query('ab_test == "interface_eu_test" & group=="B"')['user_id']
participants = test_participants.query('ab_test == "recommender_system_test" & user_id not in @excluded_user_id')
participants.describe()
| user_id | group | ab_test | |
|---|---|---|---|
| count | 5918 | 5918 | 5918 |
| unique | 5918 | 2 | 1 |
| top | D1ABA3E2887B6A73 | A | recommender_system_test |
| freq | 1 | 3385 | 5918 |
До исключения из списка пользователей экспериментальной группы B конкурирующего теста всего было 6701 уникальных участников, это ~15,8% от числа всех пользователей зарегистрированных за период 7-21 дек из EU (42340 уникальных). После очистки число участников сокращено до 5918, ~14% целевой аудитории.
#распределение регистрации пользователей-участников теста из EU
for group in ['A','B']:
df=eu_new_users[eu_new_users['user_id'].isin(participants[participants['group']==group]['user_id'])].pivot_table(index=(['first_date']), values=['user_id'], aggfunc=({'user_id' : 'count'})).sort_values(by='first_date',ascending=True)
print (f'Размер выборки группы {group} составляет {df.user_id.sum()} пользователей')
plt.figure(figsize=(20, 5))
plt.bar(df.index, df.user_id,alpha=0.8)
plt.axhline(y=200, color='black', linestyle='--')
plt.title(f'Распределение регистрации пользователей группы {group}')
plt.xlabel('Дата')
plt.ylabel('Кол-во пользователей')
plt.show()
Размер выборки группы A составляет 3195 пользователей
Размер выборки группы B составляет 2373 пользователей
Графики совпадают с общим распределением, это хороший признак качественного отбора. До обращения к логам общий размер выборки по двум группам составляет 5568 уникальных пользователя, что пока все еще несущественно ниже ожидаемой аудитории по ТЗ. Также 5568 меньше чем изначальное число отобранных участников - так как в датасете с участниками не указано откуда взят пользователь, будем в дальнейшем считать (опираясь на данные регистрации) что он либо не из EU, либо был зарегистрирован не в период 7-21 дек.
Также учтем, что всего 42340 пользователя зарегистрировались из EU, тогда доля реальных участников тестирования составит 13%, плюс они распределены одинаково по каждой неделе.
В ходе предобработки были выявлены отклонения от ожиданий ТЗ по аудитории и риск воздействия на тестируемых пользователей новогодней промо-активностью. Часть пользователей пришлось исключить из-за попадания в экспериментальную группу B другого теста, а часть из списка оказались пользователями которые зарегистрированы либо не в EU, либо после 21 декабря. Финальная аудитория составила 13,15% от числа всех пользователей, набранным из EU или 5568.
# выбираем логи только для нужных пользователей - участников теста
logs=logs[logs['user_id'].isin(participants['user_id'])]
# забираем даты регистраций в логи
logs=pd.merge(
logs,
eu_new_users[['user_id','first_date','device']],
how="left",
on='user_id',
sort=False,
copy=False,
indicator=False,
validate=None,
)
logs.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 21842 entries, 0 to 21841 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 21842 non-null object 1 event_dt 21842 non-null datetime64[ns] 2 event_name 21842 non-null object 3 details 2939 non-null float64 4 first_date 20564 non-null object 5 device 20564 non-null object dtypes: datetime64[ns](1), float64(1), object(4) memory usage: 1.2+ MB
Снова видим пропущенных пользователей, нам подойдут не все логи - причина та же (либо пользователь не из EU, либо был зарегистрирован не в период 7-21 дек), т к мы забрали данные из регистраций датасета eu_new_users, сразу избавимся от лишних записей ориентируясь на столбец 'first_date'.
logs.dropna(subset=['first_date'], inplace=True)
# переведем текстовый формат в дату
logs['first_date']=logs['first_date'].map(lambda x: dt.datetime.strptime(x, '%Y-%m-%d'))
# найдем сколько времени проходжит между событием и первой регистрацией
logs['days_from_reg'] = (logs['event_dt'] - logs['first_date']) / np.timedelta64 ( 1 , 'D')
logs.days_from_reg.describe()
count 20564.000000 mean 3.797631 std 4.143219 min 0.000046 25% 0.764485 50% 2.143559 75% 5.558209 max 23.529826 Name: days_from_reg, dtype: float64
# изучим события по частоте свершения + узнаем сколько в среднем уходит времени на действие с момента регистрации
logs.groupby('event_name', as_index=False).agg({'user_id': 'count', 'days_from_reg': 'mean'}).sort_values(by='user_id',ascending=False).reset_index(drop=True)
| event_name | user_id | days_from_reg | |
|---|---|---|---|
| 0 | login | 9306 | 3.827310 |
| 1 | product_page | 5740 | 3.809038 |
| 2 | purchase | 2804 | 3.717624 |
| 3 | product_cart | 2714 | 3.754401 |
# за сколько дней 90% пользователей совершают событие
np.percentile(logs['days_from_reg'], [90][0])
9.738877314814815
Похоже, что воронка в событиях формируется в очередности: login, product_page, product_cart, purchase, при этом купить товар можно без предпоследнего шага. И в среднем пользователи действуют быстрее чем ожидания в ТЗ (4 дня в среднем для любого события).
Теперь изучим характеристики выборок по группам.
# забираем группировку A/B в логи
logs=pd.merge(
logs,
participants[['group','user_id']],
how="left",
on='user_id',
sort=False,
copy=False,
indicator=False,
validate=None,
)
# установим обычный формат даты для дальнейшего анализа
logs['date'] = pd.DatetimeIndex(logs['event_dt']).date
# количество событий по пользователям
for group in ['A','B']:
print(
'В среднем на каждого пользователя группы {1} за весь период приходится {0} событий.'.format(round (
logs.query('group==@group').groupby('user_id', as_index=False).agg({'event_name': 'count'})['event_name'].mean()),group
)
)
В среднем на каждого пользователя группы A за весь период приходится 7 событий. В среднем на каждого пользователя группы B за весь период приходится 6 событий.
pivot_dt=logs.pivot_table(index=(['date']), columns=['group'],values=['user_id','event_name'], aggfunc=({'user_id' : 'nunique',
'event_name' : 'count'})).sort_values(by='date',ascending=True)
pivot_dt.columns=['event_count_A','event_count_B','users_A','users_B']
pivot_dt['events_per_user_A']=pivot_dt['event_count_A']/pivot_dt['users_A']
pivot_dt['events_per_user_B']=pivot_dt['event_count_B']/pivot_dt['users_B']
display(pivot_dt)
| event_count_A | event_count_B | users_A | users_B | events_per_user_A | events_per_user_B | |
|---|---|---|---|---|---|---|
| date | ||||||
| 2020-12-07 | 263.0 | 315.0 | 122.0 | 142.0 | 2.155738 | 2.218310 |
| 2020-12-08 | 283.0 | 207.0 | 133.0 | 99.0 | 2.127820 | 2.090909 |
| 2020-12-09 | 331.0 | 289.0 | 150.0 | 138.0 | 2.206667 | 2.094203 |
| 2020-12-10 | 294.0 | 218.0 | 136.0 | 109.0 | 2.161765 | 2.000000 |
| 2020-12-11 | 309.0 | 137.0 | 151.0 | 72.0 | 2.046358 | 1.902778 |
| 2020-12-12 | 319.0 | 169.0 | 151.0 | 87.0 | 2.112583 | 1.942529 |
| 2020-12-13 | 273.0 | 122.0 | 136.0 | 64.0 | 2.007353 | 1.906250 |
| 2020-12-14 | 880.0 | 227.0 | 397.0 | 111.0 | 2.216625 | 2.045045 |
| 2020-12-15 | 915.0 | 187.0 | 399.0 | 93.0 | 2.293233 | 2.010753 |
| 2020-12-16 | 870.0 | 322.0 | 384.0 | 145.0 | 2.265625 | 2.220690 |
| 2020-12-17 | 1046.0 | 230.0 | 452.0 | 106.0 | 2.314159 | 2.169811 |
| 2020-12-18 | 1085.0 | 224.0 | 473.0 | 112.0 | 2.293869 | 2.000000 |
| 2020-12-19 | 1262.0 | 235.0 | 544.0 | 114.0 | 2.319853 | 2.061404 |
| 2020-12-20 | 1298.0 | 273.0 | 565.0 | 137.0 | 2.297345 | 1.992701 |
| 2020-12-21 | 1690.0 | 358.0 | 747.0 | 168.0 | 2.262383 | 2.130952 |
| 2020-12-22 | 1114.0 | 158.0 | 497.0 | 83.0 | 2.241449 | 1.903614 |
| 2020-12-23 | 837.0 | 167.0 | 376.0 | 85.0 | 2.226064 | 1.964706 |
| 2020-12-24 | 747.0 | 131.0 | 321.0 | 63.0 | 2.327103 | 2.079365 |
| 2020-12-25 | 564.0 | 98.0 | 247.0 | 51.0 | 2.283401 | 1.921569 |
| 2020-12-26 | 504.0 | 93.0 | 218.0 | 46.0 | 2.311927 | 2.021739 |
| 2020-12-27 | 485.0 | 104.0 | 212.0 | 50.0 | 2.287736 | 2.080000 |
| 2020-12-28 | 413.0 | 82.0 | 178.0 | 46.0 | 2.320225 | 1.782609 |
| 2020-12-29 | 372.0 | 60.0 | 172.0 | 30.0 | 2.162791 | 2.000000 |
| 2020-12-30 | NaN | 4.0 | NaN | 2.0 | NaN | 2.000000 |
Видим что события в логах распределены неравномерно, ближе к концу периода сокращаются и пользователи и события (частично это связано с фильтрацией и распределением времени совершения действия). В целом в группе B оказывается меньше пользователей, и как мы выяснили, они совершают чуть меньше действий. Отбросим период когда начинается промо (25 дек) и финально изучим количество пользователей и воронки по группам.
logs=logs.query('event_dt <= "2020-12-24"')
logs.groupby('group', as_index=False).agg({'user_id': 'nunique', 'days_from_reg': 'mean'}).sort_values(by='user_id',ascending=False)
| group | user_id | days_from_reg | |
|---|---|---|---|
| 0 | A | 2279 | 2.468176 |
| 1 | B | 771 | 2.888575 |
logs_pivot = logs.groupby(['event_name','group'], as_index=False).agg({'user_id': 'nunique'}).\
sort_values(by='user_id',ascending=False).reset_index(drop=True)
# так как покупок больше чем переходов в корзину, поменяем местами пару строк для корректного отражения воронки на графике
logs_pivot.iloc[3], logs_pivot.iloc[4] = logs_pivot.iloc[4].copy(), logs_pivot.iloc[3].copy()
fig = px.funnel(logs_pivot, x='user_id', y='event_name', color='group')
fig.show()
# посчитаем конверсию к первому событию логина
test_data=logs.pivot_table(index='event_name',columns = 'group', values='user_id', aggfunc=({'user_id' : 'nunique'}))
test_data.columns = ['A','B']
test_data.sort_values(by='A',ascending=False,inplace=True)
test_data['retention_A'] = test_data['A']/test_data['A'][0]
test_data['retention_B'] = test_data['B']/test_data['B'][0]
display(test_data)
| A | B | retention_A | retention_B | |
|---|---|---|---|---|
| event_name | ||||
| login | 2279 | 770 | 1.000000 | 1.000000 |
| product_page | 1476 | 429 | 0.647652 | 0.557143 |
| purchase | 734 | 219 | 0.322071 | 0.284416 |
| product_cart | 686 | 214 | 0.301009 | 0.277922 |
Конверсия группы B c измененными рекомендациями стала хуже, ни одна метрика не улучшилась. Проведем несколько z-тестов на равенство пропорций двух выборок, чтобы определить статистическую значимость выводов.
Для проверки гипотез мы используем три сравнения пропорции событий к общему событию 'login', это небольшое число, но оно все равно приводит к увеличению вероятности проявления ошибки 1-ого и 2-ого рода при проведении статистических z-тестов.
Мы можем заведомо повлиять на снижение вероятности ошибок через использование методов корректировки уровня доверительного интервала. Например может подойти поправка Холма, т к она вместе с тем позволит сохранить мощность теста (beta=1-alpha) на более высоком уровне за счет последовательного смягчения корректировок alpha.
Примем alpha = 0.05, тогда для первой гипотезы доверительный интервал составит 0.05/3 ~ или 1,67%, а для третьей 5%. Напишем функцию которая бы последовательно корректировала интервал и принимала начальный уровень alpha.
Нулевая гипотеза для каждого теста будет значить равенство пропорций по заданным переменным success_event/trial_event для генеральных совокупностей. Альтернативная гипотеза - пропорции success_event/trial_event не равны.
""" Функция для проведения z-теста на равенство двух пропорций выборок А и B (success_event/trial_event) :
groupA,groupB - выборки А и B в формате str
success_event - название успешного события в формате str
trial_event - название количества события для отношения success_event в формате str
Содержит поправку Холма для alpha на 3 гипотезы от начального доверительного уровня alpha_0 и счетчика гипотез i_0
"""
def z_test_alpha(groupA,groupB,success_event,trial_event, alpha_0, i_0 ):
# корректировка доверительного интервала методом Холма, начальный i=0
alpha = alpha_0/(3-i_0)
# фильтрация таблицы test_data для отбора значений для проведения стат. теста:
successes = np.array([test_data[groupA].filter(items=[success_event], axis=0).reset_index(drop=True).iloc[0],
test_data[groupB].filter(items=[success_event], axis=0).reset_index(drop=True).iloc[0]])
trials = np.array([test_data[groupA].filter(items=[trial_event], axis=0).reset_index(drop=True).iloc[0],
test_data[groupB].filter(items=[trial_event], axis=0).reset_index(drop=True).iloc[0]])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print(f'p-значение сверки {success_event}/{trial_event} групп {groupA} и {groupB}: ', round(p_value,3))
if p_value < alpha:
print(f'Отвергаем нулевую гипотезу: между долями есть значимая разница на доверительном интервале {round(alpha,2)}')
print()
else:
print(f'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными на доверительном интервале {round(alpha,2)}')
print()
alpha_0=0.05
# сравним 3 пропорции между группами A и B
for n in range (0,3):
success_event=test_data.index[n+1]
trial_event='login'
z_test_alpha('A','B',success_event,trial_event, alpha_0, i_0=n)
p-значение сверки product_page/login групп A и B: 0.0 Отвергаем нулевую гипотезу: между долями есть значимая разница на доверительном интервале 0.02 p-значение сверки purchase/login групп A и B: 0.051 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными на доверительном интервале 0.03 p-значение сверки product_cart/login групп A и B: 0.225 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными на доверительном интервале 0.05
Получились интересные результаты:
Получается, что новая рекомендательная система сильно снижает конверсию в просмотр карточек, скорее всего ухудшает и просмотр корзины, но при этом не имеет воздействия на пропорцию покупок. Т е улучшить все показатели на 10% как это ожидалось не удалось, показатели наоборот упали, при этом конверсия в покупки осталась прежней.
Вывод
В ходе исследования были изучены логи событий по действиям пользователей из EU c 7 по 25 декабря. Оказалось что в изначально запланированном периоде анализа имеется пересечение с маркетиноговой активностью под новый год, в конечную выборку попали усеченные данные. В проверке гипотез было избрано 5568 уникальных пользователя, равномерно распределенными по дням в обеих группах, это ниже ожидаемого числа. Но конфигурация теста (начальные уровни конверсии, ожидаемое изменение на 10%) и распределение регистрации пользователей по дням позволяет считать что это достаточный % от нужной нам аудитории (13.5% генеральной совокупности).
В процессе изучения частоты свершения событий была установлена воронка продаж - от логина к переходу на страницу с предложениями (конверсия 63% пользователей), открытию карточки продукта (31%) и покупки (29%). Самое большое количество пользователей теряется именно после события логина в обоих группах - повод направлено поработать над улучшением этой конверсии.
В итоговых выборках по групам не отмечено аномалий в распределении уникальных пользователей/частоты событий. Сходу замечено ухудшение конверсии. Используя поправку доверительного интервала чтобы избежать ошибок и не потерять мощности теста, удалось получить статистически значимые выводы. Низкие p-value для конверсии на просмотр продукта и корзины позволяет говорить о наличии статистически значимых различиях в пропорциях выборки. Эти метрики ухудшились на 9 и 4% соответственно. При этом конверсия в покупки осталась прежней.
Приходим к выводу, что новая рекомендательная система ухудшила конверсию пользователя, и не задела результативность покупок. При лучшем понимании механики приложения(сайта) и UX пользователя можно улучшить захват на страницу предложений продуктов, сохранив такие же высокие конверсии в покупку с просмотра карточек.